Требуется проанализировать поведение пользователей мобильного приложения магазина он-лайн продаж продуктов питания.
Ввводные данные:
Порядок проведения анализа:
#импортируем нужные библиотеки
import pandas as pd
import datetime as dt
import numpy as np
import matplotlib.pyplot as plt
import plotly.express as px
import scipy.stats as stats
from statsmodels.stats.proportion import proportions_ztest
Будем работать с данными логов пользователей за определенный период, каждая запись в логе — это действие пользователя или событие. Контрольные группы - 246 и 247, экспериментальная - 248; DeviceIDHash - уникальный id пользователя
df = pd.read_csv('C:/Users/okald/datasets/logs_exp.csv', sep='\t')
df.head()
| EventName | DeviceIDHash | EventTimestamp | ExpId | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 |
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 EventName 244126 non-null object 1 DeviceIDHash 244126 non-null int64 2 EventTimestamp 244126 non-null int64 3 ExpId 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB
Считали данные, далее приведем их к удобному для работы формату.
#Изменим формат данных в колонке EventTimestamp
df['date_time'] = pd.to_datetime(df['EventTimestamp'], unit='s')
# создадим отдельную колонку только с датой события
df['date'] = df['date_time'].dt.floor('D')
df.head()
| EventName | DeviceIDHash | EventTimestamp | ExpId | date_time | date | |
|---|---|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 | 2019-07-25 04:43:36 | 2019-07-25 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 | 2019-07-25 11:11:42 | 2019-07-25 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 | 2019-07-25 11:28:47 | 2019-07-25 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 | 2019-07-25 11:28:47 | 2019-07-25 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 | 2019-07-25 11:48:42 | 2019-07-25 |
#пропущенные значения отсутствуют
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 EventName 244126 non-null object 1 DeviceIDHash 244126 non-null int64 2 EventTimestamp 244126 non-null int64 3 ExpId 244126 non-null int64 4 date_time 244126 non-null datetime64[ns] 5 date 244126 non-null datetime64[ns] dtypes: datetime64[ns](2), int64(3), object(1) memory usage: 11.2+ MB
# проверим данные на наличие полных дубликатов - их, как выясняется, 413
df.duplicated().sum()
413
#удаляем полные дубликаты
df = df.drop_duplicates().reset_index(drop=True)
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 243713 entries, 0 to 243712 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 EventName 243713 non-null object 1 DeviceIDHash 243713 non-null int64 2 EventTimestamp 243713 non-null int64 3 ExpId 243713 non-null int64 4 date_time 243713 non-null datetime64[ns] 5 date 243713 non-null datetime64[ns] dtypes: datetime64[ns](2), int64(3), object(1) memory usage: 11.2+ MB
# проверим распределение пользователей между группами - ПЕРЕСЕЧЕНИЙ ПОЛЬЗОВАТЕЛЕЙ НЕТ
intersections = df.pivot_table(index='DeviceIDHash', values='ExpId', aggfunc='nunique')\
.sort_values(by='ExpId', ascending=False)
intersections[intersections['ExpId']>1].count()
ExpId 0 dtype: int64
Таким образом, на данном этапе данные были приведены к удобному для дальнейшей работы формату, очищены от дубликатов.
# всего строк в датафрейме
display(df.shape[0])
df.EventName.count()
243713
243713
# разбивка всех записей по типам событий
df.groupby('EventName').agg({'DeviceIDHash':'count'})\
.sort_values('DeviceIDHash', ascending = False)
| DeviceIDHash | |
|---|---|
| EventName | |
| MainScreenAppear | 119101 |
| OffersScreenAppear | 46808 |
| CartScreenAppear | 42668 |
| PaymentScreenSuccessful | 34118 |
| Tutorial | 1018 |
df.DeviceIDHash.nunique()
7551
# посмотрим на распределение всех событий по пользователям
grouped_by_user = df.groupby('DeviceIDHash')\
.agg({'EventName': 'count'})\
.sort_values(by = 'EventName', ascending = False)
grouped_by_user
| EventName | |
|---|---|
| DeviceIDHash | |
| 6304868067479728361 | 2307 |
| 197027893265565660 | 1998 |
| 4623191541214045580 | 1768 |
| 6932517045703054087 | 1439 |
| 1754140665440434215 | 1221 |
| ... | ... |
| 7399061063341528729 | 1 |
| 2968164493349205501 | 1 |
| 8071397669512236988 | 1 |
| 425817683219936619 | 1 |
| 6888746892508752 | 1 |
7551 rows × 1 columns
# Считаем среднее кол-во значений на каждого пользователя
display(df.EventName.count() / df.DeviceIDHash.nunique())
grouped_by_user.mean()
32.27559263673685
EventName 32.275593 dtype: float64
Т.е. в среднем 32 события на пользователя, при этом данные варьируются в диапазоне 1-2307 событий на пользователя
# Считаем дату начала и окончания эксперимента (2 недели)
display(df['date'].min())
df['date'].max()
Timestamp('2019-07-25 00:00:00')
Timestamp('2019-08-07 00:00:00')
# Смотрим на количество событий за каждый день
df_timerange = df.groupby('date')\
.agg({'EventTimestamp':'count'}).reset_index()
df_timerange
| date | EventTimestamp | |
|---|---|---|
| 0 | 2019-07-25 | 9 |
| 1 | 2019-07-26 | 31 |
| 2 | 2019-07-27 | 55 |
| 3 | 2019-07-28 | 105 |
| 4 | 2019-07-29 | 184 |
| 5 | 2019-07-30 | 412 |
| 6 | 2019-07-31 | 2030 |
| 7 | 2019-08-01 | 36141 |
| 8 | 2019-08-02 | 35554 |
| 9 | 2019-08-03 | 33282 |
| 10 | 2019-08-04 | 32968 |
| 11 | 2019-08-05 | 36058 |
| 12 | 2019-08-06 | 35788 |
| 13 | 2019-08-07 | 31096 |
#Строим график динамики кол-ва сообытий по дням эксперимента
plt.figure(figsize=(13,6));
plt.plot(df_timerange['date'], df_timerange['EventTimestamp']);
plt.xticks(df_timerange['date'], rotation=45);
plt.ylabel('кол-во событий в день');
plt.title('Динамика количества событий по дням', fontsize = 16);
plt.grid(True, color = "grey", linewidth = "0.5");
Судя по графику, данные за июль 2019 не полные. Чтобы не исказить результаты исследования, предлагается оставить для анализа только имеющиеся данные за первую неделю августа (01/08/2019-07/08/2019)
#Создаем новый массив только с данными за нужный период
df_actual= df.query('date>="2019-08-01"').reset_index()
df_actual = df_actual.iloc[:, 1:7]
df_actual.head()
| EventName | DeviceIDHash | EventTimestamp | ExpId | date_time | date | |
|---|---|---|---|---|---|---|
| 0 | Tutorial | 3737462046622621720 | 1564618048 | 246 | 2019-08-01 00:07:28 | 2019-08-01 |
| 1 | MainScreenAppear | 3737462046622621720 | 1564618080 | 246 | 2019-08-01 00:08:00 | 2019-08-01 |
| 2 | MainScreenAppear | 3737462046622621720 | 1564618135 | 246 | 2019-08-01 00:08:55 | 2019-08-01 |
| 3 | OffersScreenAppear | 3737462046622621720 | 1564618138 | 246 | 2019-08-01 00:08:58 | 2019-08-01 |
| 4 | MainScreenAppear | 1433840883824088890 | 1564618139 | 247 | 2019-08-01 00:08:59 | 2019-08-01 |
# Проверим, что ничего не потерялось
df_timerange_actual = df_actual.groupby('date')\
.agg({'EventTimestamp':'count'}).reset_index()
df_timerange_actual
| date | EventTimestamp | |
|---|---|---|
| 0 | 2019-08-01 | 36141 |
| 1 | 2019-08-02 | 35554 |
| 2 | 2019-08-03 | 33282 |
| 3 | 2019-08-04 | 32968 |
| 4 | 2019-08-05 | 36058 |
| 5 | 2019-08-06 | 35788 |
| 6 | 2019-08-07 | 31096 |
# диапазон кол-ва дневных логов относительно узкий - 31-36 тыс. в день
plt.figure(figsize=(13,6));
plt.plot(df_timerange_actual['date'], df_timerange_actual['EventTimestamp']);
plt.xticks(df_timerange_actual['date'], rotation=45);
plt.ylabel('кол-во событий в день');
plt.title('Динамика количества событий по дням', fontsize = 16);
plt.grid(True, color = "grey", linewidth = "0.5");
plt.ylim(30000, 37000);
# Найдем число потерянных событий
display(df['EventTimestamp'].count()- df_actual['EventTimestamp'].count())
df_timerange['EventTimestamp'].sum() - df_timerange_actual['EventTimestamp'].sum()
2826
2826
#считаем долю потерянных событий
round(100-df_timerange_actual['EventTimestamp'].sum()/df_timerange['EventTimestamp'].sum()*100, 1)
1.2
Потеряли 1,2% событий, что не так много
#считаем число потерянных пользователей
df.DeviceIDHash.nunique()- df_actual.DeviceIDHash.nunique()
17
#считаем долю потерянных пользователей
round(((df.DeviceIDHash.nunique()- df_actual.DeviceIDHash.nunique()) / df.DeviceIDHash.nunique()*100), 2)
0.23
Потеряли всего 17 уникальных пользователей из 7551, т.е. 0,23% от общего числа уникальных пользователей, - тоже не много
# смотрим на распределение событий и пользователей по группам
df_actual.groupby('ExpId')\
.agg({'DeviceIDHash': ['count', 'nunique']})
| DeviceIDHash | ||
|---|---|---|
| count | nunique | |
| ExpId | ||
| 246 | 79302 | 2484 |
| 247 | 77022 | 2513 |
| 248 | 84563 | 2537 |
Пользователи есть в каждой из 3 групп, а их численность внутри групп сопоставима. Событий больше всего в экспериментальной 248 группе.
Таким образом, на данном этапе мы провели проверку данных на предмет наличия пропусков и дубликатов, определились с корректным для анализа периодом, привели данные к нужным форматам, где это было необходимо.
#Считаем количество действий пользователей в разрезе событий
events_by_type = df_actual.groupby('EventName')['DeviceIDHash'].count().reset_index()
events_by_type.columns = ['EventName', 'event_amount']
events_by_type = events_by_type.sort_values(['event_amount'], ascending = False)
events_by_type
| EventName | event_amount | |
|---|---|---|
| 1 | MainScreenAppear | 117328 |
| 2 | OffersScreenAppear | 46333 |
| 0 | CartScreenAppear | 42303 |
| 3 | PaymentScreenSuccessful | 33918 |
| 4 | Tutorial | 1005 |
# посмотрим на кол-во уникальных пользователей в разрезе событий
users_by_event = df_actual.groupby('EventName')['DeviceIDHash'].nunique().reset_index()
users_by_event.columns = ['EventName', 'unique_users']
users_by_event= users_by_event.sort_values(['unique_users'], ascending = False)
users_by_event
| EventName | unique_users | |
|---|---|---|
| 1 | MainScreenAppear | 7419 |
| 2 | OffersScreenAppear | 4593 |
| 0 | CartScreenAppear | 3734 |
| 3 | PaymentScreenSuccessful | 3539 |
| 4 | Tutorial | 840 |
# объединим эти данные в единой таблице
df_actual.groupby('EventName')\
.agg({'DeviceIDHash':['count', 'nunique']})\
.sort_values(('DeviceIDHash', 'nunique'), ascending=False)
| DeviceIDHash | ||
|---|---|---|
| count | nunique | |
| EventName | ||
| MainScreenAppear | 117328 | 7419 |
| OffersScreenAppear | 46333 | 4593 |
| CartScreenAppear | 42303 | 3734 |
| PaymentScreenSuccessful | 33918 | 3539 |
| Tutorial | 1005 | 840 |
# всего уникальных пользователей
df_actual.DeviceIDHash.nunique()
7534
users_by_event['%_users_with_this_event'] = round(users_by_event['unique_users']\
/ df_actual.DeviceIDHash.nunique()*100, 1)
users_by_event
| EventName | unique_users | %_users_with_this_event | |
|---|---|---|---|
| 1 | MainScreenAppear | 7419 | 98.5 |
| 2 | OffersScreenAppear | 4593 | 61.0 |
| 0 | CartScreenAppear | 3734 | 49.6 |
| 3 | PaymentScreenSuccessful | 3539 | 47.0 |
| 4 | Tutorial | 840 | 11.1 |
Таким образом, этап MainScreenAppear есть у 98.5% уникальных пользователей (теоретически с учетом ограниченного для анализа недельного временного интервала можно предположить, что не все попавшие в выборку уникальные пользователи начинают пользоваться приложением начиная с главного экрана, возможно, что кто-то сразу смотрит в корзину (которая могла быть сформирована ранее). Также из рекламных источников пользователи могут попадать сразу в карточки товаров, обойдя главный экран. Этап PaymentScreenSuccessful - только у 47% пользователей.
По ряду событий прослеживается четкая негативная динамика, что позволяет сделать предположение, что на каждом этапе отсеивается часть пользователей, которые уже не попадают на следующий этап. Исходя из этого можно предположить, что цепочка последовательных событий выглядит так:
Этап Tutorial видимо не является обязательным для перехода на следующий этап и может появиться в любой период между событиями пользователя. События данного этапа имеет смысл исключить из анализа воронок, иначе у нас не получится цепочка однозначных переходов.
# исключим событие Tutorial из датасета, чтобы получить корректные воронки
funnels = df_actual[df_actual['EventName']!='Tutorial']\
.groupby('EventName')['DeviceIDHash'].nunique().reset_index()\
.sort_values('DeviceIDHash', ascending = False)
funnels.columns = ['EventName', 'num_unique_users']
funnels = funnels.reset_index()
funnels = funnels.iloc[:, 1:3]
funnels
| EventName | num_unique_users | |
|---|---|---|
| 0 | MainScreenAppear | 7419 |
| 1 | OffersScreenAppear | 4593 |
| 2 | CartScreenAppear | 3734 |
| 3 | PaymentScreenSuccessful | 3539 |
# создадим новую колонку для расчета % при переходах внутри воронки
funnels['share_%'] = 0
funnels
| EventName | num_unique_users | share_% | |
|---|---|---|---|
| 0 | MainScreenAppear | 7419 | 0 |
| 1 | OffersScreenAppear | 4593 | 0 |
| 2 | CartScreenAppear | 3734 | 0 |
| 3 | PaymentScreenSuccessful | 3539 | 0 |
# Считаем % пользователей, переходящих на следующий шаг воронки с предыдущего шага
funnels['share_%']=round(funnels["num_unique_users"] \
/ funnels["num_unique_users"].shift(fill_value=7419)*100, 1)
funnels
| EventName | num_unique_users | share_% | |
|---|---|---|---|
| 0 | MainScreenAppear | 7419 | 100.0 |
| 1 | OffersScreenAppear | 4593 | 61.9 |
| 2 | CartScreenAppear | 3734 | 81.3 |
| 3 | PaymentScreenSuccessful | 3539 | 94.8 |
# Посчитаем % изменения кол-ва пользователей при переходе с шага на шаг
funnels['%_change']=round(funnels['num_unique_users'].pct_change()*100, 1)
funnels
| EventName | num_unique_users | share_% | %_change | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 7419 | 100.0 | NaN |
| 1 | OffersScreenAppear | 4593 | 61.9 | -38.1 |
| 2 | CartScreenAppear | 3734 | 81.3 | -18.7 |
| 3 | PaymentScreenSuccessful | 3539 | 94.8 | -5.2 |
data = dict(
number=funnels['num_unique_users'],
stage= funnels['EventName'])
fig = px.funnel(data, x='number', y='stage', \
title = "Изменение числа уникальных пользователей внутри воронки")
fig.show();
Таким образом, больше всего пользователей теряем на этапе OffersScreenAppear - до него доходит только 62% от пользователей, которые попали на этап MainScreenAppear (просмотр главной страницы).
Из тех пользователей, которые попали на этап OffersScreenAppear, уже 81% пользователей положат что-то в корзину.
А из тех кто положит что-то в корзину, 94% сделают покупку.
funnels['fin'] = '-'
pd.options.mode.chained_assignment = None
funnels['fin'][3]=round(funnels['num_unique_users'][3]/funnels['num_unique_users'][0]*100, 1)
funnels
| EventName | num_unique_users | share_% | %_change | fin | |
|---|---|---|---|---|---|
| 0 | MainScreenAppear | 7419 | 100.0 | NaN | - |
| 1 | OffersScreenAppear | 4593 | 61.9 | -38.1 | - |
| 2 | CartScreenAppear | 3734 | 81.3 | -18.7 | - |
| 3 | PaymentScreenSuccessful | 3539 | 94.8 | -5.2 | 47.7 |
Около 48% пользователей (вне зависимости от группы - тестовая / экспериментальная), доходят до этапа совершения покупки на сайте.
На данном этапе удалось сделать предположения о наиболее вероятной последовательности этапов использования приложения, выстроить пользовательскую воронку и оценить, как пользователи через нее проходят - на какой этап попадают почти все и как пользователи "отваливаются" внутри воронки.
#Чисто зрительно различий между группами по кол-ву логов и числу уникальных пользователей нет
df_actual.groupby('ExpId')\
.agg({'DeviceIDHash': ['count', 'nunique']})
| DeviceIDHash | ||
|---|---|---|
| count | nunique | |
| ExpId | ||
| 246 | 79302 | 2484 |
| 247 | 77022 | 2513 |
| 248 | 84563 | 2537 |
Прежде чем тестировать группы на предмет статистически значимых различий в поведении их пользователей, посмотрим на динамику логов каждой из групп
#Считаем логи по дням
df_actual_246 = df_actual[df_actual['ExpId']==246]
grouped_df_clean_246 = df_actual_246.groupby('date')\
.agg({'DeviceIDHash': ['count']}).reset_index()
grouped_df_clean_246.columns = ['date', 'logs_count_246']
grouped_df_clean_246
| date | logs_count_246 | |
|---|---|---|
| 0 | 2019-08-01 | 11561 |
| 1 | 2019-08-02 | 10946 |
| 2 | 2019-08-03 | 10575 |
| 3 | 2019-08-04 | 11514 |
| 4 | 2019-08-05 | 12368 |
| 5 | 2019-08-06 | 11726 |
| 6 | 2019-08-07 | 10612 |
# Считаем логи кумулятивно по датам
cumul_logs_246 = grouped_df_clean_246\
.apply(lambda x: grouped_df_clean_246[grouped_df_clean_246['date'] <= x['date']]\
.agg({'date' : 'max', 'logs_count_246' : 'sum'}), axis=1)\
.sort_values(by=['date'])
cumul_logs_246
| date | logs_count_246 | |
|---|---|---|
| 0 | 2019-08-01 | 11561 |
| 1 | 2019-08-02 | 22507 |
| 2 | 2019-08-03 | 33082 |
| 3 | 2019-08-04 | 44596 |
| 4 | 2019-08-05 | 56964 |
| 5 | 2019-08-06 | 68690 |
| 6 | 2019-08-07 | 79302 |
# Объединим данные по логам за день с кумулятивными данными в единую таблицу
aggregated_246 = grouped_df_clean_246.merge(cumul_logs_246, on = 'date')
aggregated_246.columns = ['date', 'logs_count_246', 'cum_logs_count_246']
aggregated_246
| date | logs_count_246 | cum_logs_count_246 | |
|---|---|---|---|
| 0 | 2019-08-01 | 11561 | 11561 |
| 1 | 2019-08-02 | 10946 | 22507 |
| 2 | 2019-08-03 | 10575 | 33082 |
| 3 | 2019-08-04 | 11514 | 44596 |
| 4 | 2019-08-05 | 12368 | 56964 |
| 5 | 2019-08-06 | 11726 | 68690 |
| 6 | 2019-08-07 | 10612 | 79302 |
# Делаем то же самое для группы 247
df_actual_247 = df_actual[df_actual['ExpId']==247]
grouped_df_clean_247 = df_actual_247.groupby('date')\
.agg({'DeviceIDHash': ['count']}).reset_index()
grouped_df_clean_247.columns = ['date', 'logs_count_247']
cumul_logs_247 = grouped_df_clean_247\
.apply(lambda x: grouped_df_clean_247[grouped_df_clean_247['date'] <= x['date']]\
.agg({'date' : 'max', 'logs_count_247': 'sum'}), axis=1)
aggregated_247 = grouped_df_clean_247.merge(cumul_logs_247, on = 'date')
aggregated_247.columns = ['date', 'logs_count_247', 'cum_logs_count_247']
aggregated_247
| date | logs_count_247 | cum_logs_count_247 | |
|---|---|---|---|
| 0 | 2019-08-01 | 12306 | 12306 |
| 1 | 2019-08-02 | 10990 | 23296 |
| 2 | 2019-08-03 | 11024 | 34320 |
| 3 | 2019-08-04 | 9942 | 44262 |
| 4 | 2019-08-05 | 10949 | 55211 |
| 5 | 2019-08-06 | 11720 | 66931 |
| 6 | 2019-08-07 | 10091 | 77022 |
# Делаем то же самое для группы 248
df_actual_248 = df_actual[df_actual['ExpId']==248]
grouped_df_clean_248 = df_actual_248.groupby('date')\
.agg({'DeviceIDHash': ['count']}).reset_index()
grouped_df_clean_248.columns = ['date', 'logs_count_248']
cumul_logs_248 = grouped_df_clean_248\
.apply(lambda x: grouped_df_clean_248[grouped_df_clean_248['date'] <= x['date']]\
.agg({'date' : 'max', 'logs_count_248': 'sum'}), axis=1)
aggregated_248 = grouped_df_clean_248.merge(cumul_logs_248, on = 'date')
aggregated_248.columns = ['date', 'logs_count_248', 'cum_logs_count_248']
aggregated_248
| date | logs_count_248 | cum_logs_count_248 | |
|---|---|---|---|
| 0 | 2019-08-01 | 12274 | 12274 |
| 1 | 2019-08-02 | 13618 | 25892 |
| 2 | 2019-08-03 | 11683 | 37575 |
| 3 | 2019-08-04 | 11512 | 49087 |
| 4 | 2019-08-05 | 12741 | 61828 |
| 5 | 2019-08-06 | 12342 | 74170 |
| 6 | 2019-08-07 | 10393 | 84563 |
plt.figure(figsize=(13,6));
plt.plot(aggregated_246['date'], aggregated_246['cum_logs_count_246'], label='A')
plt.plot(aggregated_247['date'], aggregated_247['cum_logs_count_247'], label='A1')
plt.plot(aggregated_248['date'], aggregated_248['cum_logs_count_248'], label='B')
plt.legend();
plt.title('Графики кумулятивных логов по дням по группам');
plt.ylabel('Кол-во логов в день');
Каких-либо видимых аномалий не наблюдается. Исходя из графика различий по кол-ву логов между контрольными группам 246 и 247 не наблюдается, но вот у тестовой группы 248 кумулятивное кол-во логов кажется стабильно выше тестовых Групп. Возможно поведение пользователей в этой групе существенно отличается от 2 других групп.
Требуется проверить, находят ли статистические критерии разницу между контрольными выборками 246 и 247.
#Общее кол-во уникальных пользователей в каждой группе
unique_users_in_246 = df_actual[df_actual['ExpId']==246]['DeviceIDHash'].nunique()
unique_users_in_247 = df_actual[df_actual['ExpId']==247]['DeviceIDHash'].nunique()
display(unique_users_in_246)
unique_users_in_247
2484
2513
# Переходы пользователей по разным шагам воронки для Групп 246 и 247
grouped246 = df_actual[df_actual['ExpId']==246].groupby('EventName')['DeviceIDHash']\
.nunique().reset_index()
grouped247 = df_actual[df_actual['ExpId']==247].groupby('EventName')['DeviceIDHash']\
.nunique().reset_index()
grouped246 = grouped246.sort_values(['DeviceIDHash'], ascending = False)
grouped247 = grouped247.sort_values(['DeviceIDHash'], ascending = False)
display(grouped246)
grouped247
| EventName | DeviceIDHash | |
|---|---|---|
| 1 | MainScreenAppear | 2450 |
| 2 | OffersScreenAppear | 1542 |
| 0 | CartScreenAppear | 1266 |
| 3 | PaymentScreenSuccessful | 1200 |
| 4 | Tutorial | 278 |
| EventName | DeviceIDHash | |
|---|---|---|
| 1 | MainScreenAppear | 2476 |
| 2 | OffersScreenAppear | 1520 |
| 0 | CartScreenAppear | 1238 |
| 3 | PaymentScreenSuccessful | 1158 |
| 4 | Tutorial | 283 |
# Рассчитаем воронку для каждой группы - сколько пользователей перешло на следующий этап с предыдущего в группе А
grouped246['funnel_246']=round(grouped246["DeviceIDHash"] \
/ grouped246["DeviceIDHash"].shift(fill_value=2450)*100, 1)
grouped246
| EventName | DeviceIDHash | funnel_246 | |
|---|---|---|---|
| 1 | MainScreenAppear | 2450 | 100.0 |
| 2 | OffersScreenAppear | 1542 | 62.9 |
| 0 | CartScreenAppear | 1266 | 82.1 |
| 3 | PaymentScreenSuccessful | 1200 | 94.8 |
| 4 | Tutorial | 278 | 23.2 |
# Какой это % от общего кол-ва уникальных пользователей группы 246
grouped246['%_of_unique_users_in_246'] = round(100*grouped246['DeviceIDHash']\
/unique_users_in_246, 1)
grouped246
| EventName | DeviceIDHash | funnel_246 | %_of_unique_users_in_246 | |
|---|---|---|---|---|
| 1 | MainScreenAppear | 2450 | 100.0 | 98.6 |
| 2 | OffersScreenAppear | 1542 | 62.9 | 62.1 |
| 0 | CartScreenAppear | 1266 | 82.1 | 51.0 |
| 3 | PaymentScreenSuccessful | 1200 | 94.8 | 48.3 |
| 4 | Tutorial | 278 | 23.2 | 11.2 |
# Строим аналогичную матрицу для группы 247
grouped247['funnel_247']=round(grouped247["DeviceIDHash"] \
/ grouped247["DeviceIDHash"].shift(fill_value=2476)*100, 1)
grouped247['%_of_unique_users_in_247'] = round(100*grouped247['DeviceIDHash']\
/unique_users_in_247, 1)
grouped247
| EventName | DeviceIDHash | funnel_247 | %_of_unique_users_in_247 | |
|---|---|---|---|---|
| 1 | MainScreenAppear | 2476 | 100.0 | 98.5 |
| 2 | OffersScreenAppear | 1520 | 61.4 | 60.5 |
| 0 | CartScreenAppear | 1238 | 81.4 | 49.3 |
| 3 | PaymentScreenSuccessful | 1158 | 93.5 | 46.1 |
| 4 | Tutorial | 283 | 24.4 | 11.3 |
#Объединяем матрицы групп 246 и 247
aa1 = grouped246.merge(grouped247, on = 'EventName')
aa1.columns = ['EventName', 'nu_246', 'funnel_246', '%_users_246', \
'nu_247', 'funnel_247','%_users_247']
aa1 = aa1.reset_index()
aa1 = aa1.drop('index', axis = 1)
aa1
| EventName | nu_246 | funnel_246 | %_users_246 | nu_247 | funnel_247 | %_users_247 | |
|---|---|---|---|---|---|---|---|
| 0 | MainScreenAppear | 2450 | 100.0 | 98.6 | 2476 | 100.0 | 98.5 |
| 1 | OffersScreenAppear | 1542 | 62.9 | 62.1 | 1520 | 61.4 | 60.5 |
| 2 | CartScreenAppear | 1266 | 82.1 | 51.0 | 1238 | 81.4 | 49.3 |
| 3 | PaymentScreenSuccessful | 1200 | 94.8 | 48.3 | 1158 | 93.5 | 46.1 |
| 4 | Tutorial | 278 | 23.2 | 11.2 | 283 | 24.4 | 11.3 |
# Для теста оставим только данные о кол-ве уникальных пользователей на каждом этапе
# и их % от общего числа уникальных пользователей группы
aa1_test = aa1.iloc[:, [0, 1, 3, 4, 6]]
aa1_test
| EventName | nu_246 | %_users_246 | nu_247 | %_users_247 | |
|---|---|---|---|---|---|
| 0 | MainScreenAppear | 2450 | 98.6 | 2476 | 98.5 |
| 1 | OffersScreenAppear | 1542 | 62.1 | 1520 | 60.5 |
| 2 | CartScreenAppear | 1266 | 51.0 | 1238 | 49.3 |
| 3 | PaymentScreenSuccessful | 1200 | 48.3 | 1158 | 46.1 |
| 4 | Tutorial | 278 | 11.2 | 283 | 11.3 |
Для проверки статистической значимости разницы пропорций пользователей, перешедших на каждый следующий этап, применим proportion z-тест, статистический тест для проверки значимости в отличии в долях пользователей, перешедших на следующий этап воронки. Тест считает количество успехов в анализируемой выборке, учитывая количество наблюдений.
Сформулируем гипотезы:
for i in range(len(aa1_test['EventName'].values.tolist())):
count=[aa1_test['nu_246'][i], aa1_test['nu_247'][i]]
nobs = [unique_users_in_246, unique_users_in_247]
stat, pval = proportions_ztest(count, nobs)
print('p-value для сравнения контрольных Групп А и А1 по логу', aa1['EventName'].values.tolist()[i], ':')
print('{0:.5f}'.format(pval))
p-value для сравнения контрольных Групп А и А1 по логу MainScreenAppear : 0.75706 p-value для сравнения контрольных Групп А и А1 по логу OffersScreenAppear : 0.24810 p-value для сравнения контрольных Групп А и А1 по логу CartScreenAppear : 0.22883 p-value для сравнения контрольных Групп А и А1 по логу PaymentScreenSuccessful : 0.11457 p-value для сравнения контрольных Групп А и А1 по логу Tutorial : 0.93770
Ни на одном этапе между контрольными группами нет статистически значимой разности, поэтому можно сказать, что группы между собой не отличаются. Так как проверка проводилась между двумя контрольными группами, то А/А тест мы можем считать успешным. Настройки теста и сбор данных сработал корректно.
Чтобы провести все нужные тесты, нужно провести сравнение пропорций каждого из 5 этапов для следующих комбинаций:
Для этого сначала дополним таблицу с данными для тестирования данными по группе 248, а также по смешанной Группе (246+247)
unique_users_in_248 = df_actual[df_actual['ExpId']==248]['DeviceIDHash'].nunique()
grouped248 = df_actual[df_actual['ExpId']==248].groupby('EventName')['DeviceIDHash']\
.nunique().reset_index()
grouped248 = grouped248.sort_values(['DeviceIDHash'], ascending = False)
grouped248['%_users_248'] = round(100*grouped248['DeviceIDHash']/unique_users_in_248, 1)
grouped248.columns =['EventName', 'nu_248', '%_users_248']
grouped248
| EventName | nu_248 | %_users_248 | |
|---|---|---|---|
| 1 | MainScreenAppear | 2493 | 98.3 |
| 2 | OffersScreenAppear | 1531 | 60.3 |
| 0 | CartScreenAppear | 1230 | 48.5 |
| 3 | PaymentScreenSuccessful | 1181 | 46.6 |
| 4 | Tutorial | 279 | 11.0 |
aa1_test = aa1_test.merge(grouped248, on ='EventName')
aa1_test
| EventName | nu_246 | %_users_246 | nu_247 | %_users_247 | nu_248 | %_users_248 | |
|---|---|---|---|---|---|---|---|
| 0 | MainScreenAppear | 2450 | 98.6 | 2476 | 98.5 | 2493 | 98.3 |
| 1 | OffersScreenAppear | 1542 | 62.1 | 1520 | 60.5 | 1531 | 60.3 |
| 2 | CartScreenAppear | 1266 | 51.0 | 1238 | 49.3 | 1230 | 48.5 |
| 3 | PaymentScreenSuccessful | 1200 | 48.3 | 1158 | 46.1 | 1181 | 46.6 |
| 4 | Tutorial | 278 | 11.2 | 283 | 11.3 | 279 | 11.0 |
#Считаем общее количество уникальных пользователей в 2х контрольных группах
unique_users_in_AA1 = unique_users_in_246 +unique_users_in_247
unique_users_in_AA1
4997
aa1_test['nu_AA1'] = aa1_test['nu_246'] + aa1_test['nu_247']
aa1_test['%_users_AA1'] = round(100*aa1_test['nu_AA1'] / unique_users_in_AA1, 1)
aa1_test
| EventName | nu_246 | %_users_246 | nu_247 | %_users_247 | nu_248 | %_users_248 | nu_AA1 | %_users_AA1 | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | MainScreenAppear | 2450 | 98.6 | 2476 | 98.5 | 2493 | 98.3 | 4926 | 98.6 |
| 1 | OffersScreenAppear | 1542 | 62.1 | 1520 | 60.5 | 1531 | 60.3 | 3062 | 61.3 |
| 2 | CartScreenAppear | 1266 | 51.0 | 1238 | 49.3 | 1230 | 48.5 | 2504 | 50.1 |
| 3 | PaymentScreenSuccessful | 1200 | 48.3 | 1158 | 46.1 | 1181 | 46.6 | 2358 | 47.2 |
| 4 | Tutorial | 278 | 11.2 | 283 | 11.3 | 279 | 11.0 | 561 | 11.2 |
Сформулируем гипотезы:
for i in range(len(aa1_test['EventName'].values.tolist())):
count=[aa1_test['nu_246'][i], aa1_test['nu_248'][i]]
nobs = [unique_users_in_246, unique_users_in_248]
stat, pval = proportions_ztest(count, nobs)
print('p-value для сравнения контрольных Групп 246 и 248 по логу',aa1_test['EventName'].values.tolist()[i], ':')
print('{0:.5f}'.format(pval))
p-value для сравнения контрольных Групп 246 и 248 по логу MainScreenAppear : 0.29497 p-value для сравнения контрольных Групп 246 и 248 по логу OffersScreenAppear : 0.20836 p-value для сравнения контрольных Групп 246 и 248 по логу CartScreenAppear : 0.07843 p-value для сравнения контрольных Групп 246 и 248 по логу PaymentScreenSuccessful : 0.21226 p-value для сравнения контрольных Групп 246 и 248 по логу Tutorial : 0.82643
Ни на одном этапе между контрольной группой 246 и экспериментальной 248 нет статистически значимой разности, поэтому можно сказать, что группы между собой не отличаются
Сформулируем гипотезы:
for i in range(len(aa1_test['EventName'].values.tolist())):
count=[aa1_test['nu_247'][i], aa1_test['nu_248'][i]]
nobs = [unique_users_in_247, unique_users_in_248]
stat, pval = proportions_ztest(count, nobs)
print('p-value для сравнения контрольных Групп 247 и 248 по логу', aa1_test['EventName'].values.tolist()[i], ':')
print('{0:.5f}'.format(pval))
p-value для сравнения контрольных Групп 247 и 248 по логу MainScreenAppear : 0.45871 p-value для сравнения контрольных Групп 247 и 248 по логу OffersScreenAppear : 0.91978 p-value для сравнения контрольных Групп 247 и 248 по логу CartScreenAppear : 0.57862 p-value для сравнения контрольных Групп 247 и 248 по логу PaymentScreenSuccessful : 0.73734 p-value для сравнения контрольных Групп 247 и 248 по логу Tutorial : 0.76532
Ни на одном этапе между контрольной группой 247 и экспериментальной 248 нет статистически значимой разности, поэтому можно сказать, что группы между собой не отличаются
Сформулируем гипотезы:
for i in range(len(aa1_test['EventName'].values.tolist())):
count=[aa1_test['nu_AA1'][i], aa1_test['nu_248'][i]]
nobs = [unique_users_in_AA1, unique_users_in_248]
stat, pval = proportions_ztest(count, nobs)
print('p-value для сравнения групп (246+247) и 248 по логу', aa1_test['EventName'].values.tolist()[i], ':')
print('{0:.5f}'.format(pval))
p-value для сравнения групп (246+247) и 248 по логу MainScreenAppear : 0.29425 p-value для сравнения групп (246+247) и 248 по логу OffersScreenAppear : 0.43426 p-value для сравнения групп (246+247) и 248 по логу CartScreenAppear : 0.18176 p-value для сравнения групп (246+247) и 248 по логу PaymentScreenSuccessful : 0.60043 p-value для сравнения групп (246+247) и 248 по логу Tutorial : 0.76486
Как и в других тестах, статистической значимости в разнице значений пропорций не обнаружено, поэтому можно сказать, что группы между собой не отличаются
Уровень значимости при тестировании гипотез был выбран на уровне альфа = 0.05. Всего было проведено 20 проверок гипотез. Однако при множественной проверке гипотез с каждой новой проверкой гипотезы растёт групповая вероятность ошибки первого рода (FWER). Чтобы снизить вероятность ложнопозитивного результата при множественном тестировании гипотез, применяют разные методы корректировки уровня значимости для уменьшения FWER (в т.ч. метод Бонферрони, метод Холма или Шидака).
Воспользуемся попровкой Шидака, формула которой выглядит так: $$ \ a_1 = a_2 = a_m = 1- {(1-a)}^1/m $$
alpha = 0.05
m=20
1/m
alpha_adj=round(1-(1-alpha)**(1/m), 4)
alpha_adj
0.0026
Какие выводы можно сделать имея скорректированную альфу? Какой уровень значимости стоит применить? Стоит ли изменить уровень значимости?
Логика проведения и итоги исследования:
Анализ результатов позволяет предложить следующие рекомендации: